Skip to content

fix(tui): preserve pending session work#36433

Open
kitlangton wants to merge 7 commits into
v2from
fix-initial-message
Open

fix(tui): preserve pending session work#36433
kitlangton wants to merge 7 commits into
v2from
fix-initial-message

Conversation

@kitlangton

@kitlangton kitlangton commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What

Opening or reconnecting to a V2 Session no longer drops an admitted prompt when projected-history hydration races with promotion.

Before this change, pending content was inserted into the projected-message array while a separate string array indicated which messages were still pending. Hydration had to reconcile those parallel representations, and two concurrent API reads could observe opposite sides of promotion.

After this change:

  • Projected history remains projected history.
  • Every not-yet-projected item lives in one typed timeline overlay.
  • Pending prompts retain queued styling until promotion.
  • Promoted prompts remain visible while awaiting their projected representation.
  • Queued compaction survives reconnect and becomes the running compaction row under the same stable ID.
  • Solid updates or moves existing rows rather than remounting them.

UI States

1. Admitted

The prompt is visible at the pending boundary and retains queued styling.

projected history
─────────────────
pending steer
queued prompt

2. Promoted

The same row loses queued styling but remains visible while projected history catches up.

projected history
promoted prompt
─────────────────
queued prompt

3. Projected

The server-projected message replaces the overlay representation by ID without changing the outer row owner.

projected history
promoted prompt -> projected prompt
───────────────────────────────────
queued prompt

How

Timeline Model

packages/tui/src/context/session-timeline.ts defines an exhaustive union containing only facts actually known in each state:

type SessionTimelineWork =
  | {
      kind: "input"
      id: string
      admittedSeq: number
      delivery: "steer" | "queue"
      message: SessionMessageInfo
    }
  | {
      kind: "promoted"
      id: string
      promotedSeq: number
      message?: SessionMessageInfo
    }
  | {
      kind: "compaction"
      id: string
      admittedSeq: number
    }

A promotion received before admission creates a promoted placeholder containing only its ID and promotion sequence. A later admission may provide content, but cannot downgrade it to pending.

Projected IDs always take precedence over overlay entries with the same ID.

stateDiagram-v2
    [*] --> PendingInput: input admitted
    [*] --> QueuedCompaction: compaction admitted

    PendingInput --> Promoted: input promoted
    PendingInput --> Projected: projected snapshot wins
    Promoted --> Projected: projected message observed

    QueuedCompaction --> RunningCompaction: compaction started
    QueuedCompaction --> FailedCompaction: compaction failed
    RunningCompaction --> CompletedCompaction: compaction ended
    RunningCompaction --> FailedCompaction: compaction failed

    Projected --> [*]
    CompletedCompaction --> [*]
    FailedCompaction --> [*]
Loading

Hydration Ordering

packages/tui/src/context/data.tsx reads pending work before projected history.

Promotion atomically deletes the pending record and inserts its projected message. Let P be the pending read, M the projected-message read, and X promotion. Because P < M:

  • X < P: projected history contains the input.
  • P < X < M: both may contain it; projected history wins by ID.
  • M < X: pending contains it; the live promotion operation retains it in the overlay.

Concurrent reads permit the unsafe order M < X < P, where both responses omit the input. Sequential reads eliminate that ordering.

sequenceDiagram
    participant Events as Live event stream
    participant TUI as TUI timeline
    participant Pending as Pending API
    participant Messages as Message API

    TUI->>Pending: list pending work
    Events-->>TUI: journal topology operations
    Pending-->>TUI: input + synthetic + compaction
    TUI->>Messages: list projected history
    Events-->>TUI: journal topology operations
    Messages-->>TUI: projected messages
    TUI->>TUI: verify refresh token
    TUI->>TUI: fold journal onto snapshots
    TUI->>TUI: projected IDs replace overlay entries
    TUI->>TUI: reconcile rows by stable ID
Loading

Live Operations

Only topology changes are journaled while hydration is in flight:

type SessionTimelineOperation =
  | { type: "admitted"; work: SessionAdmittedWork }
  | { type: "promoted"; inputID: string; promotedSeq: number; created: number }
  | { type: "removed"; inputID: string }
  | { type: "reverted"; to: string }

Text, reasoning, tool, shell, and compaction deltas are deliberately not replayed. They are additive, and projected responses expose no event watermark proving whether a delta is already included.

Refresh Ownership

Each Session has at most one active refresh record:

{
  token,
  operations,
  promise,
}

Same-Session callers join the promise. Reconnect invalidates active records and starts replacements for loaded, pending-only, or refreshing Sessions. Invalidated callers join the replacement when one exists. Deletion and committed revert invalidate the Session record, preventing stale installation. Failed reads install nothing and preserve visible state.

Solid Identity

packages/tui/src/routes/session/rows.ts gives every row a stable semantic ID and installs reductions with:

setRows(reconcile(next, { key: "id" }))

The same durable input ID identifies pending, promoted, and projected prompt rows. Queued and running compaction also share one ID. Tests verify that Solid retains the same row object across queued-to-running compaction and while exploration groups gain parts.

Solid and Performance

This is intentionally a correctness-first trade, not a claim that every operation is faster. Pending and projected snapshots are read sequentially because parallel reads admit the missing-input race described above. That adds the pending-endpoint RTT to hydration, but the projected history request remains capped at 200 messages and same-Session refresh callers now share one in-flight promise instead of issuing duplicate reads.

The Solid update path is efficient for the work it performs:

  • Solid stores track the proxied properties read by the row effect, not the identity of the temporary array returned by timeline.list(). Allocating that derived array does not itself schedule another update. See Solid's store tracking and fine-grained reactivity documentation.
  • Hydration installs projected messages and pending work inside one batch, so consumers observe one coherent transition rather than reducing once for each store update.
  • Message, pending-work, and row arrays use keyed reconcile. Solid reuses matching store objects, moves retained entries, and patches only changed fields. The implementation matches the installed Solid 1.9.10 behavior: reconcile source.
  • The TUI renders the reconciled row store with <For>, whose underlying mapArray caches children by item identity. Stable row proxies therefore preserve existing SessionRowView owners when a prompt or compaction moves between lifecycle positions; retained owners are moved rather than disposed and recreated. See the tagged mapArray source.
  • High-frequency text, reasoning, tool, and shell deltas stay on the existing targeted message-store path. Timeline composition and sorting run only for low-frequency topology changes such as admission, promotion, removal, revert, reconnect, or hydration.

While pending work exists, timeline derivation is linear in the visible messages plus pending work, and topology reconciliation also sorts the pending overlay. With projected history capped at 200 and pending work normally small, this is bounded CPU/allocation work. It is deliberately exchanged for race-free hydration; keyed reconciliation prevents that computation from becoming equivalent view churn.

flowchart LR
    Delta[Streaming delta] --> Targeted[Targeted message-store update]
    Targeted --> Fine[Fine-grained dependent fields]

    Topology[Topology change] --> Derive[Derive bounded timeline and rows]
    Derive --> Reconcile[Reconcile by stable row ID]
    Reconcile --> Retain[Retain or move existing row owners]
    Reconcile --> Membership[Create or dispose changed membership only]
Loading

Scope

  • No server API or persistence changes.
  • No replay of additive streaming deltas without a server snapshot cursor.
  • No new scroll anchoring policy; existing sticky-bottom behavior remains.
  • No generated-client branding changes. Promise client IDs remain strings.

The guarantee is scoped to pending-work visibility under the existing atomic-promotion, ordered-live-connection, and reconnect contracts. Recovering arbitrary silently dropped streaming events would require server replay or a snapshot cursor.

Testing

  • bun run test test/context/session-timeline.test.ts test/cli/tui/data.test.tsx test/cli/tui/session-rows.test.ts (50 passed)
  • bun typecheck
  • bun run test (247 passed, 1 skipped)
  • push-hook workspace typecheck (31 packages)

Coverage includes pending-only hydration, promotion across every snapshot boundary, late admission, promotion-before-admission, stale revert data, refresh joining and replacement, overlay-only reconnect, queued compaction restoration, queued-to-running compaction row ownership, delivery ordering, and keyed exploration-row ownership.

The full TUI suite still prints the existing missing /tmp/opencode/state/kv.json warnings; all tests pass.

packages/tui/TIMELINE_PLAN.md contains the compact final contract and proof.

Supersedes #36158.
Closes #35988

@kitlangton kitlangton changed the title fix(tui): preserve admitted messages during hydration fix(tui): hydrate pending session work Jul 11, 2026
@kitlangton kitlangton changed the title fix(tui): hydrate pending session work fix(tui): preserve pending session work Jul 11, 2026
@kitlangton kitlangton force-pushed the fix-initial-message branch from c52ef08 to d44a729 Compare July 11, 2026 18:40
@kitlangton kitlangton force-pushed the fix-initial-message branch from d44a729 to 0b1662f Compare July 11, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant